[host][linux]: Fall back to loginctl when utmp is unavailable - #2132
[host][linux]: Fall back to loginctl when utmp is unavailable#2132SebastianLeitz wants to merge 1 commit into
Conversation
Some newer distributions no longer populate /run/utmp at all, tracking logged-in sessions through systemd-logind instead. When the utmp file doesn't exist, UsersWithContext now falls back to `loginctl list-sessions -o json` followed by a `loginctl show-session` per session (run concurrently) to build the same []UserStat result, instead of returning an error. Behavior is unchanged on systems where utmp exists, including an empty one.
There was a problem hiding this comment.
Edit: This comment was posted on the wrong PR by mistake. Please disregard it.
Thank you for taking this on — this is exactly what I hoped for in #1898, and I'd like to merge it. Running the suite on a real FreeBSD kernel with no source changes is a great addition.
|
@shirou Are you sure that your comment/review is in the correct PR? The topics you discuss and the issues you reference do not have anything in common with the one I was aiming to fix. Maybe wrong browser tab? |
|
@SebastianLeitz Oh... I'm terribly sorry — I mistakenly replied to the wrong PR. My apologies for the confusion. |
shirou
left a comment
There was a problem hiding this comment.
Thank you for taking this on — and no, you didn't step out of line, this is exactly what I asked for in #1938. I'd like to merge it.
First, an apology. When I suggested loginctl list-sessions -o json in #1938, it worked — the sample JSON in that thread came from Ubuntu 24.04, which is systemd 255. But systemd 256 turned -o into a journal-only option, so list-sessions now ignores it and prints the table instead. Debian 13 (systemd 257) and Ubuntu 26.04 (systemd 259) are both past that change, which means the flag I gave you no longer produces JSON on exactly the systems this PR targets. Sorry for the bad starting point.
On my Ubuntu 26.04 box with no /run/utmp, your branch still fails — only the message changes:
--- FAIL: TestUsers
Received unexpected error: invalid character 'S' looking for beginning of value
The 'S' is the first letter of the SESSION header.
Therefore four things before merging:
- Read the column output instead of JSON.
--json=short would also work, but only from v256 (June 2024), and gopsutil should cover as many systems as it can. The column form reaches much further back — I walked the tags down to v208 (2013), and the first column has been the session id the whole way; --no-legend has been there since v210 (February 2014).
out, err := invoke.CommandWithContext(ctx, "loginctl", "list-sessions", "--no-legend", "--no-pager")
...
for _, line := range strings.Split(string(out), "\n") {
fields := strings.Fields(line)
if len(fields) == 0 {
continue
}
id := fields[0]Please use strings.Fields rather than SplitN — the session column is right-aligned in every version (printf("%10s …") up to v235, table_set_align_percent(…, 100) from v239), so short ids carry leading spaces; @nook24's paste in #1938 shows it. This also drops encoding/json and loginctlSession.
- Filter the sessions.
utmp only ever returned USER_PROCESS; list-sessions returns everything logind tracks. One SSH login gives me two sessions — class=user on pts/1 and class=manager with no tty — so Users() reports the same user twice, and on a desktop gdm's greeter becomes a logged-in user.
who on Debian 13 goes through gnulib's readutmp.c, which keeps a session only when class starts with user and at least one of seat/tty is set. Adding -p Class -p Seat to the show-session call covers it — note that -p is a show-* option, so passing it to list-sessions does nothing:
$ loginctl show-session c1 -p Class -p Seat -p TTY
Seat=
TTY=pts/1
Class=user
$ loginctl show-session c2 -p Class -p Seat -p TTY
Seat=
TTY=
Class=managerUse a prefix match rather than == "user" (user-early exists), and please don't drop on empty TTY alone — graphical logins have only a seat. Reading Class here rather than from list-sessions also matters because that column only exists from v257, whereas the properties go back to v219.
Timestampsilently yields 0 in some timezones.
%Z is numeric in a fair number of regions, and Go's MST layout rejects the minute-offset ones (+0330 Iran, +0430, +0545, +0630), so the parse fails and the discarded error leaves Started at 0. Named abbreviations and +05/-03 are fine. Dropping the abbreviation and reading the wall clock in time.Local is correct everywhere:
if f := strings.Fields(value); len(f) >= 3 {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", f[1]+" "+f[2], time.Local); err == nil {
stat.Started = int(t.Unix())
}
}- Make the
show-sessioncalls a plain loop.
sync, sessionResult and the goroutines can all go — session counts are small and Users() isn't a hot path. Passing all ids to one show-session is tempting, but a single stale id fails the whole call with exit 1, and sessions do close between the two commands.
On the tests — they pass while the real thing is broken, which is worth addressing on its own. The fake ignores everything past arg[0], so it can't tell -o json from the fix; asserting the full argument slice would have caught it. The Started expectation is computed with the same time.Parse the implementation uses, so it passes either way — a literal epoch would be better. And the os.IsNotExist branch is never exercised; common.EnvMap{common.HostVarEnvKey: t.TempDir()} gets you in there (existing example at host/host_linux_test.go:71).
Two smaller ones, take or leave: if every show-session fails we return zero users and a nil error, which is indistinguishable from "nobody is logged in"; and Users() passes context.Background() while CommandWithContext doesn't apply common.Timeout, so a wedged logind would hang it forever.
Thanks again, and sorry once more for the wrong starting point.
This is my attempt to solve #1938 - feel free to ignore if I stepped out of line.
Some newer distributions (in my case, Debian 13) no longer populate /run/utmp at all, tracking logged-in sessions through systemd-logind instead. When the utmp file doesn't exist, UsersWithContext now falls back to
loginctl list-sessions -o jsonfollowed by aloginctl show-sessionper session (run concurrently) to build the same []UserStat result, instead of returning an error. The behavior is unchanged on systems where utmp exists, which also includes an empty one.